Bonds and CDS curves

import QuantLib as ql
today = ql.Date(27, ql.April, 2025)
ql.Settings.instance().evaluationDate = today

What if you don’t have a sensible discount curve available for a corporate issue, but you have a CDS curve instead?

That’s not optimal, of course. There can be a significant basis between the CDS and bond markets (for instance, because of their different liquidity); make sure that you’re aware of any such issues before doing your calculations.

This said, let’s take a sample fixed-rate bond:

schedule = ql.Schedule(
    ql.Date(8, ql.February, 2024),
    ql.Date(8, ql.February, 2034),
    ql.Period(6, ql.Months),
    ql.UnitedStates(ql.UnitedStates.GovernmentBond),
    ql.Following,
    ql.Following,
    ql.DateGeneration.Backward,
    False,
)
settlement_days = 3
face_amount = 10_000
coupons = [0.03]
payment_day_counter = ql.Thirty360(ql.Thirty360.BondBasis)

bond = ql.FixedRateBond(
    settlement_days, face_amount, schedule, coupons, payment_day_counter
)

A risky bond engine

The RiskyBondEngine class makes it possible to calculate a price by discounting its coupons at the risk-free rate, but also weighing them by the probability that they are actually paid; that is, the probability that the issuer doesn’t default. We’ll need a risk-free curve…

dates, rates = zip(
    *[
        (ql.Date(27, 4, 2025), 0.02022),
        (ql.Date(27, 7, 2025), 0.02064),
        (ql.Date(27, 10, 2025), 0.02041),
        (ql.Date(27, 4, 2026), 0.02163),
        (ql.Date(27, 4, 2027), 0.02463),
        (ql.Date(27, 4, 2028), 0.02718),
        (ql.Date(27, 4, 2029), 0.02905),
        (ql.Date(27, 4, 2030), 0.03067),
        (ql.Date(27, 4, 2031), 0.03161),
        (ql.Date(27, 4, 2032), 0.03232),
        (ql.Date(27, 4, 2033), 0.03305),
        (ql.Date(27, 4, 2034), 0.03358),
        (ql.Date(27, 4, 2035), 0.03402),
        (ql.Date(27, 4, 2040), 0.03565),
        (ql.Date(27, 4, 2045), 0.03619),
        (ql.Date(27, 4, 2050), 0.03619),
        (ql.Date(27, 4, 2055), 0.03620),
    ]
)

risk_free_curve = ql.ZeroCurve(dates, rates, ql.Actual365Fixed())

…and a default probability curve, that can be bootstrapped from CDS quotes.

cds_data = [
    (ql.Period(1, ql.Years), 0.004),
    (ql.Period(2, ql.Years), 0.008),
    (ql.Period(3, ql.Years), 0.013),
    (ql.Period(5, ql.Years), 0.021),
    (ql.Period(7, ql.Years), 0.027),
    (ql.Period(10, ql.Years), 0.034),
]

fixed_rate = 0.01
recovery_rate = 0.4
cds_settlement_days = 1
upfront_settlement_days = 3

helpers = [
    ql.UpfrontCdsHelper(
        quote,
        fixed_rate,
        tenor,
        cds_settlement_days,
        ql.UnitedStates(ql.UnitedStates.GovernmentBond),
        ql.Quarterly,
        ql.ModifiedFollowing,
        ql.DateGeneration.CDS2015,
        ql.Actual360(),
        recovery_rate,
        ql.YieldTermStructureHandle(risk_free_curve),
        upfront_settlement_days,
    )
    for tenor, quote in cds_data
]

probability_curve = ql.PiecewiseFlatHazardRate(
    today, helpers, ql.Actual360()
)

Once we have those, we can instantiate the engine, set it to the bond, and retrieve the results we want:

bond.setPricingEngine(
    ql.RiskyBondEngine(
        ql.DefaultProbabilityTermStructureHandle(probability_curve),
        recovery_rate,
        ql.YieldTermStructureHandle(risk_free_curve),
    )
)
bond.cleanPrice()
87.59096931191402

Back to rates

If we want to translate this into a corresponding credit spread to be applied on top of the risk-free curve, we can create a discount curve with a spread yet to be determined:

credit_spread = ql.SimpleQuote(0.0)

discount_curve = ql.ZeroSpreadedTermStructure(
    ql.YieldTermStructureHandle(risk_free_curve),
    ql.QuoteHandle(credit_spread),
)

Then, we can use the CDS-based price as a target…

target_price = bond.cleanPrice()

…and set a new engine to the bond. Next we define a function that, given a value for the spread, calculates the corresponding bond price and returns its difference from the target price; we can then pass it to a solver that finds its zero, i.e., the spread for which the bond price is the same as the CDS-based price.

bond.setPricingEngine(
    ql.DiscountingBondEngine(ql.YieldTermStructureHandle(discount_curve))
)
def objective_function(s):
    credit_spread.setValue(s)
    return bond.cleanPrice() - target_price
solver = ql.Brent()
spread = solver.solve(objective_function, 1e-7, 0.005, 0.0001)
spread
0.013749869755662938

We can check that the price is indeed the same, within numerical tolerance:

credit_spread.setValue(spread)
bond.cleanPrice()
87.59096931265552

We can also express the spread as a difference between bond yields; namely, the yield that we can calculate based on the target price…

y0 = bond.bondYield(
    ql.BondPrice(target_price, ql.BondPrice.Clean),
    payment_day_counter,
    ql.Compounded,
    ql.Semiannual,
)
y0
0.047452630996704104

…and the one we can obtain from a risk-free bond price, that we can obtain by using the risk-free curve for discounting:

bond.setPricingEngine(
    ql.DiscountingBondEngine(ql.YieldTermStructureHandle(risk_free_curve))
)
risk_free_price = bond.cleanPrice()
risk_free_price
97.4066704451667
y1 = bond.bondYield(
    ql.BondPrice(risk_free_price, ql.BondPrice.Clean),
    payment_day_counter,
    ql.Compounded,
    ql.Semiannual,
)
y1
0.03343150448799134

The difference between the two yields is comparable to the z-spread we calculated earlier; given the different conventions (the z-spread is continuously compounded) we didn’t expect them to be the same.

y0 - y1
0.014021126508712761